test

2.2 Java虚拟机

尽管使用平台无关的字节码可以完全满足对可移植性的要求,但实际上,CPU本身并不能直接执行字节码指令,它只认识特殊的指令集。

在本书中,专用于某个硬件架构的代码被称为 本地代码(native code)。例如,对于x86平台来说,x86汇编语言和x86机器代码就是本地代码。机器代码是2进制的平台相关的代码,汇编语言是以人类可读的形式表示的机器代码。

因此,JVM需要将字节码转化为本地代码供CPU执行。具体实现有以下两种方式(也可能会综合使用这两种方式):

  • JVM规范将JVM描述为一个状态机器,因此实际上并不需要真的将字节码转化为本地代码执行。JVM可以模拟Java程序的执行状态,例如可以将字节码模拟为一个JVM状态函数。这种方式称为 字节码解释执行,在这种情况下,唯一直接执行的本地代码(这里暂不考虑JNI)就是JVM本身。

  • JVM将字节码编译为针对某种平台的本地代码,然后再调用本地代码执行。一般情况下,将字节码编译为本地代码这一步是发生在某个方法第一次被调用的时候。这个过程就是众所周知的 即时编译(Just-In-Time Compilation)

编译为本地代码后,程序的执行效率会比解释执行快几个数量级,不过,这是以额外的信息记录和编译时间为代价的。

2.2.1 基于栈的虚拟机

JVM是一种基于栈的虚拟机,绝大部分字节码操作都涉及到操作数栈中操作数的入栈和出栈。例如,在执行求和操作时,会将两个操作数入栈,将两个数做加法后再将结果入栈,使用结果的时候再将操作结果出栈。除了栈之外,字节码的格式还规定了有多达65536个寄存器可以使用,也称之为 局部变量

在字节码格式中,操作指令都被编码在一个字节中,也就是说,JVM最多支持256种 操作码(opcode)(译者注:这里的原文是 Java supports up to 256 opcodes,后文中如有类似问题也会进行替换),每种操作都有唯一值和类似于汇编指令的 助记符

长久以来,JVM只增加了一个新的操作码,即0xba,这个值是为了在将来提供对 invokedynamic操作的支持而预留的。该操作用于解决将动态语言(例如Ruby)编译为JVM字节码时遇到的动态分派(dynamic dispatch)问题。更多有关将字节码应用于动态语言的内容,请参见 Java Specification Request (JSR) 292的描述。

2.2.2 字节码格式

下面的代码是一个执行加法的函数及其编译后的字节码格式:

public int add(int a, int b) {
    return a + b;
}

public int add(int, int);
  Code:
    0: iload_1    // stack: a
    1: iload_2    // stack: a, b
    2: iadd       // stack: (a+b)
    3: ireturn    // stack:
}

函数add有两个输入参数ab,分别被放入局部变量1和局部变量2中(根据JVM规范,实例方法局部变量0中存放是this)。前两个操作,即iload_1iload_2,用于将局部变量1和局部变量2中的值放入到操作数栈中。第三个操作iadd从操作数栈中弹出两个数,对其求和,并将结果入栈。第四个操作ireturn弹出之前计算出的和,以该值作为返回值,方法结束。上面例子中的每一步字节码操作旁边都有关于操作数栈操作的注释,读者可自行揣摩。

使用JDK附带的命令行工具javap可以对字节码进行反汇编

2.2.2.1 操作与操作数

JVM字节码是一种非常紧凑的格式,前面例子中的方法的字节码表示只用了4个字节。每种操作都使用一个字节表示,后跟一个可选的、长度可变的操作数,一般情况下,带有操作数的字节码指令的长度不会超过3个字节。

下面的代码是判断一个数是否是偶数的函数,及其编译为字节码后的样子:

public boolean even(int number) {
    return (number & 1) == 0;
}

public boolean even(int);
  Code:
    0: iload_1      // 0x1b number
    1: iconst_1     // 0x04 number, 1
    2: iand         // 0x7e (number & 1)
    3: ifne 10      // 0x9a 0x00 0x07
    6: iconst_1     // 0x03 1
    7: goto 11      // 0xa7 0x00 0x04
    10: iconst_0    // 0x03 0
    11: ireturn     // 0xac
}

在上面的代码中,首先将传入的参数number和常数1压入到操作数栈中,然后将它们都弹出求和,即执行iand指令,并将结果压入操作数栈。指令ifne进行条件判断,从操作数栈中弹出一个操作数做比较判断,如果不是0的话,就跳转到其他分支运行。指令iconst_0将常数0压入到操作数栈中,其操作码为0x03,其后无操作数。类似的,指令iconst_1会将常量1压入操作数栈中。返回值为布尔类型时是使用常量整数来表示的。

比较和跳转指令,例如ifne(如果不相等则跳转,字节码是0x9a),通常需要使用两个字节的操作数(以满足16位跳转偏移的要求)。

举个例子,如果有一个操作是经过条件跳转判断后需要将指令指针向前移动10000个字节的话,那么这个操作的编码应该是0x9a 0x27 0x10(注意,0x2710是10000的16禁止表示,字节码中数字的存储是大端序的)。

字节码中还包含其他一些复杂结构,例如分支跳转,是通过在tableswitch指令后附加包含了所有跳转偏移的分支跳转表实现的。

2.2.2.2 常量池

程序,包含数据和代码量部分,而数据则作为操作数使用。对于字节码程序来说,如果操作数非常小而又很常用,则这些操作数是直接内嵌在字节码指令中的,(译者注,例如iconst_0)。

较大块的数据,例如常量字符串或比较大的数字,是存储在class文件开始部分的常量池中的。要使用数据作为操作数时,使用的是常量池中数据的索引位置,而不是实际数据本身。如果在编译方法时每次都要重新编码字符串aVeryLongFunctionName的话,那字节码就谈不上压缩存储了。

此外,Java程序中,方法、域和类的元数据也是class文件的组成部分,存储在常量池中。